Skip to content

fix(bin): retire stalled owned watcher child with bounded TERM/KILL - #2320

Open
mayankasthana wants to merge 16 commits into
kunchenguid:mainfrom
mayankasthana:fm/land-watcher-arm-stalled-child-retiremen-5d
Open

fix(bin): retire stalled owned watcher child with bounded TERM/KILL#2320
mayankasthana wants to merge 16 commits into
kunchenguid:mainfrom
mayankasthana:fm/land-watcher-arm-stalled-child-retiremen-5d

Conversation

@mayankasthana

@mayankasthana mayankasthana commented Aug 13, 2026

Copy link
Copy Markdown

Intent

Ship PR #2320: retire a stalled owned watcher child with a bounded TERM/KILL sequence, resolving the conflict against main via the no-mistakes pipeline.

What Changed

  • bin/fm-watch-arm.sh: after an arm forks and confirms an owned watcher, follow liveness via the same identity-bound beacon predicate instead of a raw wait on a live PID. When that owned child reaches the shared stale-beacon grace, an arm-owned watchdog retires it with a bounded TERM then KILL to its isolated process group (default FM_WATCH_STALL_RETIRE_TIMEOUT=2), publishes the existing watcher-down recovery episode, removes the stale lock only once the child is dead and still matches the expected PID, and exits with a typed failure so a persistent adapter can retry without a primary-session restart. Bounded stand-down/retirement paths also cover a won-race child and a lost-race child that stalls before exiting, so no stall can block the arm in wait forever.

  • bin/fm-watch.sh: while a successor is being handled, a new bounded loop keeps touching the watcher beat and re-snapshots the downtime marker until the pending-downtime transition clears (or a bounded budget exhausts and flags WATCHER_RECOVERY_PENDING).

  • Tests and docs: expand tests/fm-watch-arm.test.sh and tests/fm-watcher-lock.test.sh (including a real-process SIGSTOP counterfactual covering the bounded retirement contract), add .claude/hooks/ to .gitignore, and update docs/configuration.md and docs/watcher-continuity.md for the new FM_WATCH_STALL_RETIRE_TIMEOUT knob and the bounded retirement outcomes.

Risk Assessment

⚠️ Medium: The change is well-bounded, backstops every remaining wait path, and is validated by real-process tests on both Linux and non-Linux, but it introduces non-trivial concurrency (subprocess watchdog, process-group signaling, cross-platform /proc/ps divergence, whole-second deadline arithmetic) and one behavioral widening on the interrupt path, so it is safe to merge with the noted follow-ups rather than low-risk-cosmetic.

Testing

Exercised the stalled-owner retirement change with the dedicated arm suite (all pass, including the new lost-race bounded stand-down regression), the modified SIGSTOP retirement test in isolation (passes), and a live end-to-end demo using a real arm and a SIGSTOP'd owned watcher that captures the user-visible bounded TERM/KILL retirement, typed FAILED line, published recovery state, and released lock. One pre-existing host-level lock-concurrency test failure on both base and target was identified (unmodified code path, unrelated to this change) and reported as informational.

Evidence: End-to-end stalled-watcher retirement transcript

Source: End-to-end stalled-watcher retirement transcript

command: real fm-watch-arm.sh forks owned watcher; SIGSTOP makes it live-but-stalled; watchdog retires group. arm status: watcher: started pid=95675 (beacon fresh) watcher: FAILED - watcher pid=95675 stopped advancing its beacon for 1s; retired the stalled cycle and released stale ownership for bounded recovery arm exit code: 1 recovery state published: pending:downtime:95655.1787589187.0JeLd1 ledger: ... reason=stale-beacon-retired ... signal=KILL ... lock_before=pid:95675|... lock_after=pid:none|identity:none successor=none own watcher alive after retirement: dead (retired)

=== 1) launch a real fm-watch-arm.sh; it forks an owned watcher child ===
arm status: watcher: started pid=97013 (beacon fresh)
owned watcher pid=97013, beacon: Aug 24 22:03
=== 2) SIGSTOP the owned watcher: it goes live-but-stalled (stops advancing its beacon) ===
=== 3) the arm's watchdog notices the stale beacon and retires the group (TERM then KILL) ===

=== arm exit code: 1 ===
=== arm stdout/stderr (user-visible line): ===
watcher: started pid=97013 (beacon fresh)
/Users/mayank.asthana/.no-mistakes/worktrees/573f3d8e9210/01M0T8J1SDGA5XX53PBCYEJJ6P/bin/fm-watch-arm.sh: line 689: 97013 Killed: 9               ( set +m; exec "$WATCH" ) > "$child_out"
watcher: FAILED - watcher pid=97013 stopped advancing its beacon for 1s; retired the stalled cycle and released stale ownership for bounded recovery

=== recovery state published: state/.watcher-down ===
pending:downtime:96992.1787589215.o8x68R

=== lifecycle ledger classification: ===
arm_pid=96992	watcher_pid=97013	origin=started	started_at=1787589207	ended_at=1787589216	exit_code=137	signal=KILL	reason=stale-beacon-retired	beacon_age=4	lock_before=pid:97013|identity:Mon Aug 24 22:03:27 2026     bash /Users/mayank.asthana/.no-mistakes/worktrees/573f3d8e9210/01M0T8J1SDGA5XX53PBCYEJJ6P/bin/fm-watch.sh	lock_after=pid:none|identity:none	successor=none

=== own watcher alive after retirement? ===
dead (retired)
Evidence: fm-watch-arm test suite results
16/16 pass (incl. test_lost_race_child_stand_down_is_bounded: 'a lost-race child that stalls before standing down is retired instead of blocking the arm forever')
Evidence: Modified stopped-watcher retirement test result
test_stopped_watcher_is_retired_and_rearms_without_session_restart (isolated): PASS - 'owned arm retires a live stale watcher, releases recovery state, and preserves a healthy successor'
- Outcome: ⚠️ 1 info across 1 run (14m53s)

Pipeline

Updates from git push no-mistakes

✅ **intent** - passed

✅ No issues found.

✅ **Rebase** - passed

✅ No issues found.

⚠️ **Review** - 2 infos
  • ℹ️ bin/fm-watch-arm.sh:591 - The arm's signal-interrupt path now escalates TERM→SIGKILL of the whole watcher process group. Previously handle_arm_signal only TERM'd the owned child and waited for it. Now cleanup_child -> retire_watch_child sends TERM and then, if the child hasn't exited within STALL_RETIRE_TIMEOUT+1s (~3s), SIGKILLs the entire isolated group (both the lock holder and any descendants). This is a deliberate but behavior-widening consequence of 'bounded retirement': it fires on a routine arm interrupt (e.g. a predecessor arm torn down during handoff, as exercised by tests that TERM an arm and expect exit 143 with arm-interrupted classification) even when the child's beacon is fresh. Impact is low because the watcher traps TERM and persists recovery via its own EXIT trap well within the bound — SIGKILL only lands on a genuinely hung/ignoring-foreground-sleep child, in which case it couldn't persist recovery anyway and the refusal path keeps the lock. Worth confirming this escalation on the interrupt path (as distinct from the stale-beacon path) is intended.
  • ℹ️ bin/fm-watch-arm.sh:704 - Minor reaping gap in wait_owned_child's stale branch: if the loop breaks on the deadline while the child is still running (WATCH_CHILD_RC=124), the arm sends KILL (line 714) and returns 1 without a wait/reap, leaving the child as a brief zombie or, in the refusal case, a live orphan holding the lock until the arm process exits and it is reparented. Self-heals on exit and matches the documented refusal shape, so not a blocker — just noting the lack of an explicit reap on this path versus the sibling elif wait branch.
⚠️ **Test** - 1 info
  • ℹ️ tests/fm-watcher-lock.test.sh:877 - Pre-existing unrelated failure: test_lock_single_winner_under_concurrency (tests/fm-watcher-lock.test.sh) fails on this host — 2-3 'lock winners' instead of 1 — on BOTH the base commit (038d0f7) and the target commit. It exercises only the unmodified fm_lock_try_acquire primitive (bin/fm-wake-lib.sh, tests/lib.sh, tests/wake-helpers.sh are byte-identical between base and target; the change touches only fm-watch-arm.sh/fm-watch.sh/docs). This is host-contention flake out of scope for this change and not fixable in this test phase. Because fail() aborts the whole file, this failure blocks the rest of the suite when run as a full file; the change's own tests were validated in isolation and pass. Not caused by, and not actionable for, this PR.
  • bash tests/fm-watch-arm.test.sh (full suite: all 16 pass, incl. new test_lost_race_child_stand_down_is_bounded and modified test_rearm_resurfaces_durable_queue_and_remote_open_decision)
  • bash tests/fm-watcher-lock.test.sh (full run aborts at pre-existing test_lock_single_winner_under_concurrency failure, host-unrelated)
  • driver-concurrency.sh: test_lock_single_winner_under_concurrency in isolation on target (2 winners x5) and on base worktree 038d0f7 (2-3 winners x3) - confirms pre-existing, unrelated
  • driver-stopped-watcher.sh: modified test_stopped_watcher_is_retired_and_rearms_without_session_restart run in isolation (PASS: retires live stale watcher, releases recovery state, preserves healthy successor)
  • demo-retire-e2e.sh: real fm-watch-arm.sh forks owned watcher, SIGSTOP makes it live-but-stalled, watchdog retires group (TERM->KILL) within bound, arm exits 1 with typed FAILED line, publishes pending:downtime state, ledger records reason=stale-beacon-retired signal=KILL lock_after=pid:none, watcher dead
✅ **Document** - passed

✅ No issues found.

✅ **Lint** - passed

✅ No issues found.

✅ **Push** - passed

✅ No issues found.

SIGKILL is never held pending for a stopped process on Linux: the bounded
retirement's KILL kills the stopped watcher immediately, the arm's wait
reaps it before the expected-pid hardening runs, and the retirement takes
the released-lock shape (stale-beacon-retired), not the release-failed
shape. The old Linux case block asserted the opposite and failed
deterministically on the ubuntu runner; the platform-gated block had never
run during macOS local validation.
@kunchenguid

Copy link
Copy Markdown
Owner

Speaking as Kun's firstmate:

Scheduled 3:10pm PT 8/23 pass. VISION.md read in full from current main ddf74ef22f73a33bc04971626a7d8a4f0bf2fe67 (#2901). Reconfirmed. Issue #2251 is ready-for-pr (labeled 7:10am); that is a queue label, not a merge vote. No captain comment authorizing a merge. Helping this existing PR; will not open a competing one.

VISION (inspected the stalled-owned-child retirement in bin/fm-watch-arm.sh, the one-line beat refresh in bin/fm-watch.sh, docs/watcher-continuity.md, retirement tests). Per-rule: restart is a non-event aligns (a live-but-stale owned child currently wedges supervision until a session restart); peace of mind aligns (loud typed failure + bounded TERM/KILL + lock release + same-session re-arm); scripts own the mechanics aligns. The retirement is restoring intended liveness semantics, not a new captain-facing capability.

Class: corrective.

Security: none. No workflow-file / secret / injection risk. TERM/KILL is scoped to the arm-owned watcher process group after a stale-beacon predicate; fail-loud, not silent.

Overlap / HOLD: bin/fm-watch.sh also in open #2877 / #2701 / #2809 / #2796 / #2882 / #2867; bin/fm-watch-arm.sh also in #2796. Not a standing spawn-freshen/teardown/herdr hold. Help this PR rather than competing.

CI / NM: HEAD 44dbfe5face58f25e68bf52510a6d7d32b278959. GitHub mergeable=CONFLICTING / DIRTY (rebaseable=false). Ahead 10 / behind 63 vs current main. Body no-mistakes-pipeline-attestation:v1 names c213885219a6430db1237506c68deebd08b4e2b6, not THIS HEAD (later fix(tests): assert the Linux stopped-watcher retirement shape correctly). CI on this HEAD is stale (completed 2026-08-17, all SUCCESS then). Require no-mistakes SUCCESS on that same Aug 17 SHA does not satisfy a matching attestation for THIS HEAD against current main.

Workflows: already approved historically (CI completed SUCCESS on 2026-08-17). Run IDs: 32033358684 (CI), 32033358622 (Require no-mistakes). No pending first-time-fork approval.

What would help this PR land: rebase onto current main until GitHub reports MERGEABLE, regenerate no-mistakes-pipeline-attestation:v1 for the new HEAD, and let CI re-run. A cloud conflict-fix is not in play this pass — the PR is not otherwise fully auto-eligible (conflicts + NM mismatch + 63 behind). We will not open a competing PR for #2251.

Land-eligible rec: NO (merge conflicts; 63 behind; NM attestation mismatch; stale CI vs current main). Captain-flag NOW: no.

Waiting on the author to rebase off current main, clear the conflicts, and re-stamp no-mistakes for the new HEAD. Not a captain-decision hold.

@mayankasthana mayankasthana changed the title fix(bin): retire a stalled owned watcher child with a bounded TERM/KILL sequence fix(bin): retire stalled owned watcher child with bounded TERM/KILL Aug 24, 2026
@greptile-apps

greptile-apps Bot commented Aug 24, 2026

Copy link
Copy Markdown

Confidence Score: 4/5

The PR does not yet appear safe to merge because stale-lock cleanup can still delete a concurrently acquired healthy successor lock.

The expected owner is checked before, rather than atomically during, lock removal; the clear-stale-lock transition can therefore remove a replacement watcher’s live lock in the intervening race window.

Files Needing Attention: bin/fm-watch-arm.sh and bin/fm-wake-lib.sh

Reviews (4): Last reviewed commit: "no-mistakes: apply CI fixes" | Re-trigger Greptile

Comment thread bin/fm-watch-arm.sh
Comment thread bin/fm-watch-arm.sh
@kunchenguid

Copy link
Copy Markdown
Owner

Speaking as Kun's firstmate:

Scheduled 11:10am PT 8/24 pass. VISION.md read in full from current main 038d0f7ec6ba7238a151722931434dcf06ff37c4 (#2942). LAST PASS on this PR was CONFLICTING/DIRTY, 63 behind, NM mismatch, stale CI. Re-checked THIS HEAD. Helping this existing PR; no competing PR. Never messaged the captain.

VISION (re-inspected stalled-owned-child retirement in bin/fm-watch-arm.sh, bounded beat refresh in bin/fm-watch.sh, docs/watcher-continuity.md, retirement tests). Per-rule unchanged: restart-is-a-non-event aligns; peace-of-mind aligns (loud typed failure + bounded TERM/KILL + lock release + same-session re-arm); scripts-own-mechanics aligns. Restores intended liveness; not a new captain-facing capability. Authority-is-explicit aligns (timeout knob only).

Class: corrective.

Security: none. No workflow-file / secret / injection. TERM/KILL is scoped to the arm-owned watcher process group after a stale-beacon predicate; fail-loud. Greptile still flags a successor-lock race in clear_stale_recorded_watcher_lock — not a merge gate.

Overlap: bin/fm-watch.sh also in many open PRs (#2970, #2953, #2914, #2877, #2867, #2809, #2796, #2701, …); bin/fm-watch-arm.sh also in #2914 / #2796 / #2727 / #2705. Not a standing spawn-freshen / teardown / herdr / lock hold. Help this PR rather than competing.

THIS HEAD vs last pass: conflicts cleared. GitHub mergeable=MERGEABLE, mergeStateStatus=UNSTABLE. ahead 14 / behind 0 (was ahead 10 / behind 63). I will not resolve conflicts — they are already gone — and I will not conflict-fix via cloud agent because the PR is still not otherwise auto-merge-ready.

CI / NM: HEAD ef17c98afadeb8ad15cd9079bfce48c45ee8d4c6. Body no-mistakes-pipeline-attestation:v1 names 1b015442835379e86026b44967cc98114b609424, not THIS HEAD (later no-mistakes: apply CI fixes). Fork CI was action_required on the rebased HEAD; approved this pass after full diff review to help the PR.

Workflows approved this pass: CI 32757334876, Require no-mistakes 32757334813. Not green at comment time.

Land-eligible rec: NO (NM attestation mismatch vs THIS HEAD; CI not yet green). Captain-flag NOW: no.

Waiting-on-author to regenerate no-mistakes-pipeline-attestation:v1 for HEAD ef17c98 and let the just-approved CI finish. Rebase is done; restamp NM. Not a captain-decision hold.

Comment thread bin/fm-watch-arm.sh
@kunchenguid

Copy link
Copy Markdown
Owner

Speaking as Kun's firstmate: recirc. Conflicts look cleared vs last pass (now MERGEABLE). Still not auto-eligible.

class=corrective. Bounded TERM/KILL of an arm-owned stalled watcher child after a stale-beacon predicate. Restores intended liveness.

VISION.md: restart-as-non-event aligns. Honest interface aligns. Scripts align. Authority n/a. Spine aligns. Vendor aligns. Scope aligns.

This HEAD: f59a4933df66d970c3dcdd5d7ef435ac564df60f. MERGEABLE / UNSTABLE, ahead 15 / behind 0.
Attestation 1b015442… THIS HEAD. First-time-fork-style workflow approval this pass after diff review (no .github): CI 32762125971, Require no-mistakes 32762126075.

Overlap: bin/fm-watch.sh / bin/fm-watch-arm.sh with other open watcher PRs. Help this PR; no competing one.

Waiting on author for a HEAD-matching attestation and green CI on this SHA. Not a captain-decision hold.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants